Skip to content

Agent-driven task decomposition via the aeos skill - #2

Merged
lycha merged 14 commits into
mainfrom
feat/agent-decomposition
Jul 22, 2026
Merged

Agent-driven task decomposition via the aeos skill#2
lycha merged 14 commits into
mainfrom
feat/agent-decomposition

Conversation

@lycha

@lycha lycha commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Agent-driven task decomposition, plus a run of fixes and observability
improvements found while dogfooding the epic pipeline end-to-end.

Decomposition

  • TASK_BREAKDOWN is agentic: the architect calls aeos ticket create --parent --key to create child tasks itself (idempotent on --key).
  • Child tickets now carry their full task content (--body/--body-file)
    and parent lineage in the document metadata (Kind / Parent / Task key) —
    previously they were created title-only.
  • Architect prompt hardened against fabricated tool-permission refusals in both
    TECH_SPEC (artifact mode) and TASK_BREAKDOWN (agentic).

Orchestrator & escalation

  • Actionable halt message for a ticket stuck WORKING/IN_REVIEW.
  • Escalation reason/message/artifact persisted on the ticket (migration v5)
    and surfaced by aeos ticket show and the orchestrator halt — no more
    "NEEDS_HUMAN with no explanation".

Observability

  • Live run display shows the working agent, and the orchestrator progress
    output shows task + column (STAN-2 [IMPLEMENTATION]: …).

Project maintenance

  • New aeos project sync reconciles a project's scaffolded column specs,
    agents, and rubrics with the current templates (dry-run / --diff /
    --apply / --force with .bak backups). Closes the "project scaffolded
    from an old template stays stale forever" gap.

All green: typecheck, lint, build, 653 tests.

🤖 Generated with Claude Code

lycha and others added 14 commits July 22, 2026 12:04
TASK_BREAKDOWN previously stopped at AWAITING_DECOMPOSITION and required a human
to retype the breakdown into `ticket create --parent` calls. The architect now
creates the child tickets itself.

Approach: rather than parsing tasks out of the artifact, ship an `aeos` skill
that teaches an agentic executor to call the CLI. One canonical copy lives at
.aeos/skills/aeos/, relative-symlinked into each executor's skills dir
(.claude/skills, .augment/skills) by `project init`, so a single definition
serves whichever backend runs — the executor abstraction stays intact. Falls
back to a copy where symlinks are unavailable.

TASK_BREAKDOWN becomes agentic; the architect writes tasks.md and calls
`aeos ticket create "<title>" --parent <epic>` per task. Three supporting
changes make that safe:

- `ticket create` is idempotent by (parent, title): a matching child
  short-circuits to the existing ticket. The review loop may retry the
  breakdown, and a retry must not duplicate tasks.
- New column-spec flag `requiresRepoDiff` (default true) decouples "needs
  agentic/tool execution" from "must leave a repo diff". TASK_BREAKDOWN sets it
  false — creating tickets and writing a gitignored artifact is not a repo edit,
  so the IMPLEMENTATION diff guarantee must not fail it.
- The orchestrator needs no decomposition step: children created during the run
  are picked up by its existing drive-all-children logic on the next tick.
  AWAITING_DECOMPOSITION remains the fallback when none appear (AEOS_EXECUTOR=stub
  can't shell out, so offline runs still halt there).

Verified end to end: a fresh `project init` links the skill into both executor
dirs, and creating the same task twice under an epic short-circuits to the
existing ticket.

620 tests, typecheck, lint, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four findings from the review of the decomposition PR.

1. Portability (skill-source.ts): the finding was that opencode-cli gets no
   skill link and would silently stall. In fact the decomposition instruction
   is carried by the architect's taskInstruction (the prompt), which reaches
   every executor — the skill is only reinforcement. Corrected the comment to
   document the portable carrier so this isn't misread again.

2. Idempotency keyed on title, but the retry it guards against is exactly when
   titles drift: the review loop re-runs the agent, which may rephrase a task's
   wording, and title-only dedup would then duplicate it. Added a stable
   decomposition key — `aeos ticket create --key T-NNN`, persisted as task_key
   (migration v4, idempotent), matched under a parent in preference to the
   title. The architect and skill now pass the T-NNN label. Falls back to title
   when no key is given.

3. Tickets are created mid-run before review, so an abandoned breakdown leaves
   orphan BACKLOG children that block the epic's DONE-join. The key fix stops
   retries from accumulating (same keys reuse); a genuinely removed task is
   cleared by the operator via `ticket move <id> DONE`, which the join's
   blocker list points at. Auto-deleting user tickets on a breakdown change is
   deliberately avoided. Documented.

4. skill-source had no packaging guard, unlike the sibling template-source: a
   missing skills/ dir would throw a cryptic ENOENT mid-scaffold. Added an
   explicit packaging-fault error.

Verified end to end: a task re-created with a rephrased title but the same
--key short-circuits to the existing ticket; migration v4 applies cleanly to a
v3 database and is idempotent.

624 tests, typecheck, lint, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four findings from reviewing the --key fix itself.

1. The prompt claimed a keyed retry "updates in place," but the code returns the
   existing ticket unchanged — a reworded title was silently discarded. Aligned
   the architect prompt and skill with the actual behaviour: a key match leaves
   the task unchanged, so keep titles stable across attempts too.

2. A reused key silently drops a task (the second create matches the first by
   key). The example hardcoded `--key T-001`, inviting copy-paste. Now shows two
   distinct keys and states plainly that each task needs its own key.

3. A keyed lookup ignored keyless children, so switching a task from unkeyed to
   keyed across attempts duplicated it. Added a keyless-title bridge: when a key
   finds no match, a keyless child with the same title (a pre-key attempt) is
   matched instead. It only matches keyless children, so it can't collide with a
   differently-keyed task.

4. `taskKey` was persisted even without a parent, contradicting the port's
   "ignored without a parent". Now gated: an epic never carries a key.

Verified end to end: an unkeyed create then a keyed retry of the same title does
not duplicate; an epic created with a stray --key stores no key.

627 tests, typecheck, lint, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two of five review findings applied; three skipped with reason.

Applied:
- TASK_BREAKDOWN is agentic, which grants the agent Write/Edit tools, and the
  prompt told it to "write the tasks.md artifact at your output path". But AEOS
  captures the agent's stdout as the artifact and never passes an output path —
  a file the agent writes is ignored. If the agent wrote a file and printed only
  a summary, tasks.md would be a thin stub (failing minWordCount, or misreviewed).
  Reworded the architect prompt and skill: produce the breakdown as response
  text; a written file is ignored, only printed output is captured.
- skill-source: replaced the hand-rolled recursive copyDir with fs.cpSync
  (recursive), available on the required Node 22.

Skipped:
- Keyless-bridge match doesn't backfill the key onto the matched child, so a
  keyless-first task that is later rephrased under a key can still duplicate.
  Fixing needs a new repo mutator and the template always keys from the start —
  rare mixed-mode edge, left as a defensive-only bridge.
- The idempotency read is outside createAtomic's transaction, so concurrent
  same-key creates could duplicate. Enforcing needs a UNIQUE constraint that
  would throw on the race; the agent creates tasks sequentially. Out of scope.
- linkOrCopy skips a dangling executor symlink rather than repairing it.
  Repairing changes the intentional skip-existing behaviour for a rare case.

627 tests, typecheck, lint, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`orchestrator run` printed only plain step lines; a standalone `ticket run`
shows a live split-pane view. Now the orchestrator forwards each inner ticket
run's event stream to that same display, so an orchestrated epic renders the
same interface per ticket.

- OrchestratorObserver gains an optional ticketRunObserver; the use case
  forwards every inner ticket-run event to it (alongside the lock heartbeat).
- The command reuses createTicketRunDisplay: started lazily on the first event
  (so an immediate halt prints its summary rather than flashing a blank
  screen), torn down before the end-of-run summary. In a TTY the summary
  replays the step lines the live pane suppressed; without a TTY, steps print
  inline as before.
- ticket-run-display gained the two cases it was missing — ticket-run.escalated
  and run.attempt.started — and now resets its per-run panels on
  ticket-run.started so a display reused across an epic's tickets reflects the
  current ticket (raw log kept for scroll-back). This also fixes escalation not
  showing in a standalone `ticket run`.

628 tests, typecheck, lint, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The orchestrator had no interrupt path, so Ctrl+C mid-run killed the process
ungracefully (and, before the terminal-restore fix, left the alt-screen active).
It now stops like `ticket run` does.

- OrchestratorPort gains interrupt(): sets a flag and forwards to the in-flight
  ticket run's interrupt() (which kills its executor child). No-op when idle.
- The run loop checks the flag before scheduling the next action and again right
  after each one, so it stops as soon as the interrupted ticket unwinds rather
  than scheduling more work. New HaltReason.INTERRUPTED; the epic is left IDLE
  (not RUNNING), so it is not locked out and re-running resumes.
- The flag is reset at the start of each run, so a stray interrupt with nothing
  running does not poison the next run.
- The command captures the orchestrator instance and wires SIGINT ->
  display.requestInterrupt() + orchestrator.interrupt(), removing the handler in
  both success and error paths. Exit code 130 on interrupt (conventional).

Verified end to end: SIGINT mid-run halts with INTERRUPTED, exit 130, epic left
IDLE with a resume message.

631 tests, typecheck, lint, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A ticket left WORKING or IN_REVIEW (a run killed before it could transition —
a crash or a pre-graceful interrupt) fell through to the generic halt: "…which
the orchestrator has no action for," which tells the operator nothing.

The orchestrator still can't tell a live run from an orphan, so it correctly
declines to touch the ticket — but the recovery is concrete. It now names it:
"reset it with `aeos ticket ready <id>` and re-run." Applies to both an epic
stuck in its own column and a child task.

633 tests, typecheck, lint, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… CLI

The pipeline computed a precise escalation reason (ITERATIONS_EXHAUSTED,
NOT_CONVERGING, UNPARSEABLE_VERDICT, PREFLIGHT_BLOCKERS) and a human-readable
message, then threw both away at end-of-run — they lived only in the live run
event. An operator asking "why did this stall?" later had to reverse-engineer
it from the review artifact on disk, and `orchestrator status` could only say
"needs a human", which is exactly what escalation.ts promises it won't.

Now the escalation is persisted on the ticket (migration v5: escalation_reason
/message/artifact) and surfaced:
  - `aeos ticket show`  → a Reason: line + the artifact to read first
  - `aeos orchestrator status` / halt messages → the reason inline

The reason is cleared automatically the moment the ticket leaves ESCALATED/
BLOCKED — the clear lives in updateSubState, the single chokepoint for sub-state
changes, so every path (run start, `ticket ready`, advance) drops a stale reason
with no call-site churn.

Tests: repo round-trip + retain/clear invariant, policy message includes the
reason. 639 tests, typecheck, lint, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TECH_SPEC runs in artifact mode: the agent's stdout IS the artifact (the adapter
writes it), and `claude --print` grants no file-write permission. But the
architect's TECH_SPEC instruction said "Produce a detailed technical
specification as a single Markdown file" — so the agent reached for the Write
tool, had it denied in headless mode, narrated "file write permissions aren't
being granted. Let me output directly instead", and dumped a *partial summary*
to stdout. That truncated, error-prefixed text became the artifact and the
reviewer rejected it (2 blockers: leaked-tool-error, spec-content-not-reproduced).

Mirror the capture contract already stated for TASK_BREAKDOWN: print the full
spec as the response, never use Write/Edit, and don't hedge into a summary.

TASK_BREAKDOWN (agentic, does need Bash to create tickets) is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
During a run — standalone or orchestrator-driven — the display named the stage
("Worker") and the executor/model, but not *which agent* was working: architect,
pm, and reviewer all showed up as "Worker". You couldn't tell what the pipeline
was actually doing at a glance.

Thread the agent spec name through the stage.started event (worker, reviewer,
preflight) and render it:
  - live meta line + summary pane → "Agent: architect-agent"
  - plain/log mode → "[stage:worker] started | … | agent=architect-agent"

The column (pipeline stage) was already in the header; this fills the other half
of "where are we and who's working". 640 tests, typecheck, lint, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n TASK_BREAKDOWN

TASK_BREAKDOWN is agentic and the executor grants the Bash tool, so
`aeos ticket create` runs without any approval step — verified by reproducing
the exact `claude -p … --permission-mode acceptEdits --allowedTools Bash,…`
invocation, which created a ticket cleanly. Yet the architect (opus) sometimes
narrates "aeos ticket create requires approval and was not granted in this
non-interactive session… re-approve aeos and I'll create the tickets" and
creates nothing — a fabricated permission block, the same excuse-narration
failure mode as the TECH_SPEC "file write permissions aren't being granted" one.

Tell the agent explicitly that the CLI is pre-authorized, to run it directly,
never claim it needs approval, and that a breakdown with no child tickets is a
failed run. Same discipline guard already added for TECH_SPEC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rrent templates

A project scaffolded by an older `project init` keeps its original column specs,
agents, and rubrics forever — template fixes never reach it, and the drift only
surfaces when a ticket reaches the affected column (e.g. TASK_BREAKDOWN still on
executorMode: artifact, so the architect had no Bash tool to create child
tickets). There was no way to see or close that gap short of hand-diffing files.

`aeos project sync`:
  - default: dry run — lists missing / drifted / up-to-date files
  - --diff:  line diff (LCS, context-collapsed) for each drifted file
  - --apply: writes missing files (always safe)
  - --force: also overwrites drifted files, backing each up to <file>.bak first

No three-way merge: AEOS doesn't store the template version a file came from, so
it can't distinguish a local edit from a template change within one file. Sync
is honest about that — it never silently overwrites a local edit; --force keeps
a .bak so a change like `advanceMode: auto` is recoverable.

Hexagonal: TemplateCatalog (driven) → ProjectSyncUseCase → ProjectSyncPort →
command; FsTemplateCatalog reuses readTemplates(). 648 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…content

Two gaps in agentic decomposition made child tickets nearly useless to the
engineer that later implements them:

1. No lineage in the ticket document. The AEOS:METADATA block recorded only
   Column and Sub-state — a task's own document never named its parent epic or
   kind. Add Kind, and Parent/Task key when present.

2. Only a title, no content. `aeos ticket create` accepted just a title, so the
   architect's `create "<title>" --parent … --key …` dropped every task's
   Description, Acceptance criteria, Touches, and Out-of-scope from tasks.md.
   The child then ran IMPLEMENTATION from a bare title.

   Add `--body`/`--body-file` to `ticket create`; the body becomes the ticket's
   Description verbatim (placeholders remain for hand-created tickets). The
   architect prompt and the aeos skill now write each task's block to a temp
   file and pass `--body-file`, so the full breakdown reaches the child.

Verified end-to-end: a created task shows Kind/Parent/Task-key metadata and the
task body in its Description. 653 tests, typecheck, lint, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
During an orchestrator run it was hard to tell which task was being driven and
at what pipeline stage. The live pane's header already showed ticket + column,
but the orchestrator's own step lines and the plain-mode/scrollback output did
not.

- OrchestratorStep now carries the column it acted on (captured before the
  action, so an 'advance' reports the stage it drove, not the destination).
- Progress lines render it: `STAN-2 [IMPLEMENTATION]: run succeeded …` — both
  the inline non-TTY prints and the end-of-run replay.
- The run-started log line now includes the column too, so plain mode and live
  scrollback show `[run] started | STAN-2 | IMPLEMENTATION | executor=…`.

653 tests, typecheck, lint, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@lycha
lycha merged commit 494c432 into main Jul 22, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant